Skip to content

rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope - #16143

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-15685-references-refusal-envelope
Sep 6, 2026
Merged

rest/meta: the /references door answers both 501 refusals in one ADR-0112 envelope#16143
os-zhuang merged 4 commits into
mainfrom
claude/issue-15685-references-refusal-envelope

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15685

GET /api/v1/meta/:type/:name/references could refuse in two ways, and the two answers agreed on neither the envelope nor the message. Driven through the real route on one boot:

before after
A — the protocol cannot answer for this TARGET type (a field, #9327) 501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"} 501 {"error":{"code":"NOT_IMPLEMENTED","message":"[unanswerable_target] References to a 'field' item cannot be computed. … Ask the owning object instead: GET /api/v1/meta/object/account/references."}}
B — the resolved kernel has no findReferencesToMeta at all (#9326) 501 {"error":{"code":"NOT_IMPLEMENTED","message":"protocol.findReferencesToMeta() is not available in this kernel"}} unchanged
body.error.code undefined on A, NOT_IMPLEMENTED on B NOT_IMPLEMENTED on both
top-level sibling body.code NOT_IMPLEMENTED on A, absent on B absent on both

Two facts were lost on A, and both of them are the operator's.

The prescription. A's message is not decoration — findReferencesToMeta says so in as many words ("The message is prescriptive per ADR-0110 D3: it names the answerable question"). It tells the operator what to ask INSTEAD, because a field's dependents ARE reachable, through the object that owns it. That sentence is what keeps "the question was never asked" from being read as "nothing depends on it" (ADR-0110 D3, #8896) in front of an operator whose next click is a delete — this door backs the admin "Used by" panel, whose empty case renders "Nothing in the metadata graph points at this item. Safe to delete." On the wire the sentence had become "Internal server error".

The code's position. It read on B and was undefined on A — and this route's own comment on the B branch warns against exactly that dialect ("never the bare-string or sibling-code dialects, which make body.error.code read undefined"). One route was violating its own written rule at its other exit, reached by a different path.

Route taken, and the two not taken

A reached the wire through handleRouteErrordeclaredServerFaultAnswer, which keeps status and code and replaces the prose with INTERNAL_ERROR_MESSAGE. That arm is correct and argued for a server FAULT (#11718, #5582): a fault message may carry driver internals and withholding it is the point. What it cannot see is that a producer-declared 5xx may be a deliberate REFUSAL whose message is authored FOR the caller.

Three repairs were possible and only one is a dev's to take:

  • (a) teach the relay the refusal/fault distinction — changes platform-wide behaviour for every producer-declared 5xx at every door. Maintainer's call.
  • (b) this route catches its protocol's refusal and re-answers it in the nested envelope its sibling exit already publishes — bounded to one door. Taken.
  • (c) move the refusal off 5xx entirely — a published route changing status band. Maintainer's call.

(b) is also what the producer already believed was happening: findReferencesToMeta's own comment says its refusal "reuses the ADR-0112 nested envelope and the SAME 501 NOT_IMPLEMENTED code the sibling refusal on this exact route already returns. One route, one dialect for 'the question was never asked'." The relay was silently making that false. This restores the door to its written contract rather than inventing one.

(b)'s known incompleteness is written down rather than left implied, and is handed back as its own finding: declaredServerFaultAnswer still cannot distinguish a deliberate refusal from a server fault, so every OTHER producer-declared 5xx refusal in the repo still loses its prose at every door.

How narrow the arm is

notImplementedRefusalAnswer (module-scope in rest-server.ts) fires only on a producer-declared 501 carrying this route's own published refusal code and a non-empty message, and the catch is scoped to the findReferencesToMeta call ALONE — so what it can re-dress is mechanically the set of things the protocol raised; neither resolveProtocol nor the resolveExecCtx seam above it can reach the arm, whatever they declare.

Matching the literal code rather than "any declared code" is also how the #9232 vocabulary narrowing is honoured by CONSTRUCTION: an unregistered producer spelling can never reach this exit, so no second copy of thrownCodeFields' demotion rule is needed and no new door ships an un-narrowed code. The cost is stated in the docblock rather than hidden: a future SECOND refusal code on this route falls back to the flat fault answer until whoever adds it comes here.

The producer's message is relayed VERBATIM, [unanswerable_target] prefix included. Editing it would be a third local opinion at a boundary whose module argues against exactly that; the prefix is the producer's own prose and is filed as a separate observation.

declaredHttpStatus is exported from error-response.ts so the arm asks the existing two-spelling question (status / statusCode, #7525) instead of re-deriving it. A read, no wire byte moves, and error-response.ts is not part of @objectstack/rest's package entry, so nothing published changes.

Measurement

⚠️ The card's repro command is NOT the criterion. rest-server-meta-read-org-scope.test.ts -t 'unanswerable-target' reads the code through both dialects on purpose, so it is green under both shapes — before and after. Reproduced: under the ablation below it stays GREEN while the new pin goes red.

New pin: packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts — 7 tests, both refusals driven on the real route table, assertions POSITIONAL because position is half the finding. It pins the prescription reaching the caller, body.error.code reading the same way on both refusals in ONE comparison, and the arm's boundaries: a producer-declared 503 keeps its withheld generic message and its flat body (driver internals asserted absent), and a 501 declaring a code this door does not publish stays on the fault terminal. Anti-vacuity: an answerable target on the same harness still answers 200 {"references":[]}, and INTERNAL_ERROR_MESSAGE is pinned to the exact text A used to ship.

Ablation, direction predicted in writing before the run: delete the route's re-dress arm, restoring the pre-fix const result = await …; res.json(result);. Predicted RED on exactly two of the seven — the prescription test and the code-position test — with the other five green and the org-scope file untouched.

Observed, exactly that:

× names the answerable question instead of "Internal server error"
× both are the ADR-0112 NESTED envelope, with the code in ONE place
  AssertionError: expected [ undefined, 'NOT_IMPLEMENTED' ] to deeply equal [ Array(2) ]
Test Files  1 failed | 1 passed (2)
Tests  2 failed | 46 passed (48)

The mutation was confirmed on disk before the run (arm call sites 1 → 0, pre-fix spelling restored 0 → 1, blob hash differs from the HEAD blob) and the restore was proved after it (git diff HEAD empty, restored blob byte-identical to the HEAD blob). No build leg: packages/rest/dist does not exist in this worktree and the pins import ./rest-server.js relatively, so vitest resolves source.

Suite: pnpm --filter @objectstack/rest exec vitest run — 186 files, 3168 tests, green. pnpm --filter @objectstack/rest run typecheck green, and its check:test-typecheck half compiles the test layer under tsconfig.test.json; all three touched test files were confirmed present in that program with tsc --listFiles, so the coverage claim is measured rather than assumed.

Gates, harvested on this PR's head c747061cfcc, exit codes captured BEFORE any pipe. node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands derives 55 commands on the code-only diff and 92 once the doc anchor repair joins it, asserted against its own Reconciliation — 92 famil(ies) line. 90 of 92 exit 0. The two that do not are NOT MEASURED, not passes: check:dual-build-cjs-loads and check:type-check-debt both exit 3 with PREREQUISITE NOT MET — each reads built output for every package and asks for a whole-repo pnpm build, which does not fit a foreground turn on a shared box. CI runs both after its own build. The 36 Artifact rosters were run separately and are not part of that 92: 32 green; check-partof-closing-keyword.mjs and check-single-claim-paths.mjs exit 2 = NOT WIRED (no PR_BODY / PR_NUMBER outside CI — and their pnpm spellings resolve to --self-test only, which grades the checker's fixtures, not this diff, per #16030); check:react-declaration-parity prints "MANIFEST is not set … This gate did NOT run"; check:published-readme-exports exits 3, same whole-build prerequisite.

Three gate-owned follow-ons, each its own commit and none a behaviour change: the new fixture's findOne opens with assertEngineFindOnePredicate and the RETAINED ledger records the pin; ten anchors in content/docs/permissions/system-context.mdx were re-anchored by check-system-context-census.mjs --fix after the arm and its helper moved every elevation read site below them (+88 above the route, +109 below it), pure line rot with no row added, removed or re-worded; and the execCtx census records 77 → 77 sites / 99 → 100 mentions — one prose mention, no call site, which is that two-number control working.

Changeset

patch on @objectstack/rest — a bug fix in a released package. Not breaking: nothing an author can write is removed or renamed, no exported surface is withdrawn, and @objectstack/client already reads the code from either position (errorBody?.code ?? errorBody?.error?.code), so err.code is unchanged for SDK callers while err.message improves from Internal server error to the prescriptive sentence. No **BREAKING** banner and therefore no ADR-0087 disposition marker; check:adr-0087-registration and check:changeset-no-major are both green above. The changeset body states the position change for raw HTTP callers.

Not folded in, deliberately

#15620 (the falsified getMetaItems comment on this same door) and #15622 (the unspecified-type sweep's organization forwarding, a decision box) are separate cards on the same door and stay separate. #13753 is the organization-forwarding half and moves no status code, field or message; the one place this PR touches its pin is the #9327 refusal read, whose comment described the divergence as standing and is rewritten rather than left falsified.

⚠️ For whoever squashes: the authoritative card relation is the single Fixes #15685 above. The per-commit trailers were written before that convention was applied on this branch and the follow-on commits carry Part of #15685; rewriting them would need an amend or a force-push, both of which are off-limits here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N


Generated by Claude Code

…0112 envelope

`GET /api/v1/meta/:type/:name/references` could refuse in two ways and the two
answers agreed on neither the envelope nor the message:

  A  protocol cannot answer for this TARGET type (a `field`)
     501 {"error":"Internal server error","code":"NOT_IMPLEMENTED"}
  B  the resolved kernel has no `findReferencesToMeta` at all
     501 {"error":{"code":"NOT_IMPLEMENTED","message":"…not available in this kernel"}}

Two facts were lost on A, and both of them are the operator's. The
PRESCRIPTION: A's message names the question that IS answerable ("Ask the
owning object instead: GET /api/v1/meta/object/<owner>/references"), which is
what keeps "the question was never asked" from being read as "nothing depends
on it" in front of an operator whose next click is a delete — ADR-0110 D3, and
`findReferencesToMeta` says the message is prescriptive for that reason in as
many words. On the wire it was replaced by "Internal server error". And the
`code`'s POSITION: `body.error.code` read on B and `undefined` on A, the very
dialect this route's own comment on the B branch warns against.

A reached the wire through `handleRouteError` -> `declaredServerFaultAnswer`,
which is correct for a server FAULT (#11718, #5582) and cannot see that a
producer-declared 5xx might be a deliberate REFUSAL whose message is authored
for the caller. Teaching the relay that distinction would change behaviour for
every producer-declared 5xx at every door; this takes the bounded half instead —
one door re-dressing one refusal in the dialect it already publishes. The
general question is handed back as its own finding.

The arm is keyed to a declared 501 carrying this route's own published refusal
code and a non-empty message, and the catch is scoped to the protocol call
alone, so neither `resolveProtocol` nor the `resolveExecCtx` seam can reach it.
A `sys_metadata` outage's 503 still propagates withheld, flat and logged.

`rest-server-meta-references-refusal-envelope.test.ts` drives both refusals on
one boot and pins the prescription and the `code` position together, with the
withheld-503 and unpublished-501 controls that keep the arm from reading as
"5xx prose is public now". The existing #9327 pin in
`rest-server-meta-read-org-scope.test.ts` reads the code through both dialects
on purpose and is green either way; its comment described the divergence as
standing, so it is rewritten rather than left falsified.

Fixes #15685

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…n in its diff

`toContain` on an absent nested message fails with a matcher TYPE complaint
rather than with the finding — measured under ablation. `toEqual` +
`stringContaining` prints `undefined` against the expected sentence, which is
what a reader of a red run needs to see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…n census

Three gate-owned follow-ons to the refusal-envelope repair, none of them a
behaviour change:

- `check:engine-double-contract`: the new fixture's `findOne()` now opens with
  `assertEngineFindOnePredicate` — a fake looser than `ObjectQL.findOne` is how
  a dead REST route once shipped with a green suite — and the RETAINED ledger
  records the new pin so it protects this file from here on.
- `check:system-context-census`: inserting the refusal arm and its helper moved
  every elevation read site below them in `rest-server.ts` (+88 above the route,
  +109 below it). Ten anchors in `content/docs/permissions/system-context.mdx`
  re-anchored by the gate's own `--fix`; pure line rot, no row added, removed or
  re-worded.

Part of #15685

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
…nt adds

77 -> 77 sites / 99 -> 100 mentions. The `/references` refusal arm adds ONE
prose mention and NO call site: its comment records that the arm's catch is
scoped to the protocol call alone, so the `resolveExecCtx` seam above it cannot
reach the arm whatever that seam declares — the safety claim the narrow catch
rests on. The site count not moving is this two-number control working.

Part of #15685

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added the size/m label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 8 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line), getReferences (sdk, the bare tail of client method meta.getReferences, bound to GET /api/v1/meta/:type/:name/references), meta.getReferences (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/references, selected by route anchor /meta/:type/:name/references))
  • content/docs/api/data-api.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line))
  • content/docs/api/data-flow.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/api/error-catalog.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line))
  • content/docs/api/wire-format.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line), /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/data-modeling/queries.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line))
  • content/docs/permissions/authentication.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line))
  • content/docs/protocol/diagram.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/protocol/objectql/query-syntax.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object (route, a path literal on a changed line))
  • content/docs/ui/react-pages.mdx (via /:type/:name/references (route, bridged from symbol notImplementedRefusalAnswer — its route source's handler names it))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx (via NOT_IMPLEMENTED (literal, a string literal in notImplementedRefusalAnswer; a string literal on a changed line), /api/v1/meta/object (route, a path literal on a changed line))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 13 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 932acc3df41e016812cfc17ffe1527fd7b5df538packageMentionDocs.

Which tree this was computed on

This run read content/docs from d99611f9360f8612e24e721024ae687b94af4361 — the merge of head c747061cfcc05f1db9148db110ace7555bbda24d into base 932acc3df41e016812cfc17ffe1527fd7b5df538, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d99611f9360f8612e24e721024ae687b94af4361 && git checkout d99611f9360f8612e24e721024ae687b94af4361
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 932acc3df41e016812cfc17ffe1527fd7b5df538 c747061cfcc05f1db9148db110ace7555bbda24d && git checkout -B drift-repro 932acc3df41e016812cfc17ffe1527fd7b5df538 && git merge --no-ff c747061cfcc05f1db9148db110ace7555bbda24d

node scripts/docs-audit/affected-docs.mjs --json 932acc3df41e016812cfc17ffe1527fd7b5df538

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 932acc3df41e016812cfc17ffe1527fd7b5df538 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Contributor

Contract review (clause ②) — PASS — PR #16143 at head c747061c (Fixes #15685)

Director seat, summon #15, session_01TezFG8ZMrNH6n5VTNpPpdH (os-zhuang), 2026-09-06T03:40Z, batch review under the maintainer's 「按批次执行完所有的契约复审」. Tier fuse: get_session this session reads session_context.model = last_served_model = CONTRACT_REVIEW_TIER. Readings from the PR diff (8 files), card #15685 + triage 5549089572, the seat's claim 5556522555; dev report 5556556128 read afterwards as cross-check.

Implemented-by: session_01D47qPfEWVPmhguWgBZCi5N os-dev round (branch claude/issue-15685-references-refusal-envelope)
Reviewed-by: session_01TezFG8ZMrNH6n5VTNpPpdH

Clause ② standing — yes (content limb), correctly declared

Limb 1: no published symbol moves — declaredHttpStatus becomes a module export of error-response.ts, which is not on @objectstack/rest's package entry. Limb 2: yes — refusal A on GET /api/v1/meta/:type/:name/references changes envelope on the wire: nested {error:{code,message}} instead of flat {error:"Internal server error",code}; body.error.code starts reading, the top-level sibling body.code stops, and the producer's prescriptive sentence reaches the caller.

① Derived judgments

# claim reading verdict
1 Route (b) — catch the protocol's own refusal at this one door and re-answer in the sibling exit's envelope; (a) relay-wide refusal/fault distinction and (c) leaving 5xx are the maintainer's Right allocation: (b) restores the door to what findReferencesToMeta's own comment already claimed ("one route, one dialect"); (a) would move every producer-declared 5xx at every door. The residual generality is handed back as a finding, not smuggled. correct
2 notImplementedRefusalAnswer fires only on declared 501 (via declaredHttpStatus, both spellings) + literal code === 'NOT_IMPLEMENTED' + non-empty message; catch scoped to the findReferencesToMeta call alone Diff read; a 503 outage still reaches handleRouteError withheld; resolveProtocol / resolveExecCtx cannot reach the arm. Literal-code match honours the #9232 closed vocabulary by construction; the stated cost (a second refusal code falls back to the fault terminal) is visible. correct
3 Message relayed verbatim, [unanswerable_target] prefix included — filed separately (#16145) rather than edited at the transport Correct restraint. correct
4 Pins: 7 cases, positional envelope assertions, both refusals on the real route table, controls for the 503 fault and an unpublished 501 code; ablation 2 red / 5 green as predicted; the card's own repro command correctly identified as non-discriminating Discriminating direction. correct
5 Census page re-anchored by --fix (10 anchors, pure line rot); engine-double-contract.pinned.json +5 for the new fixture's findOne Tool output on os-regen path; gate baseline growth for a new double. accepted

② semver

@objectstack/rest patch. Accepted with the note the changeset already carries: a raw HTTP caller branching on the top-level body.code for this route's 501 must read body.error.code — the SDK reads either position, err.code unchanged. The route's written contract was the nested shape; this is a fix restoring it, the same class as #16044 (patch). In the fixed group the level is moot in effect.

③ Boundary flags

  • The relay-wide question (a deliberate producer-declared 5xx refusal keeps its prose vs a fault withholds it) is a real, repo-wide contract question the seat says it files as a finding — that card is the maintainer's decision box, not this review's.
  • Per-commit trailers say Part of #15685 while the body says Fixes #15685; squash uses the PR body, so the card closes on merge. Noted so nobody re-edits history.

Evidence and landing

Checks on c747061c: 35 success / 10 skipped / 0 red; mergeable_state: clean; check-governed-merges --test on the 8 paths: 0 hits — ordinary queue landing. Clearing, same stroke: needs:contract-review off #15685 and PR #16143 with provenance; then check-clause2-carriers --pair 16143 ⇒ ready + auto-merge SQUASH from this seat.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rest/meta: the /references door's unanswerable-target 501 loses its prescriptive ADR-0110 D3 message — one route, two refusal envelopes

3 participants